Day 26 完成了 Docker Compose 部署,系統可以一行指令啟動。但 Day 24 留下了一個沒解決的警訊:在一份 58 條需求的規格書 srs_medium.md 上,LLM 補充層回報了 17 個衝突,全部是誤報;規格書自己列出的 3 個已知矛盾,則一個都沒找到。當時的補充 prompt 已經用掉 2,623 個 token,接近 Mistral 7B 的 4096 上限。
第 3 週的所有結論(F1 0.897)都來自 Day 7-10 的資料集:每份只有 8-11 條需求,而且刻意放滿衝突。真實的規格書通常更長、衝突更少。今天要用數據回答:這套系統能處理多大的規格書?規模變大時,是哪一層先出問題?
第 4 週:發佈與開源
讀完這篇後,你會完成:
src/benchmark.py:任意大小、衝突已知、可重現OLLAMA_NUM_CTX、OLLAMA_TIMEOUT、截斷警告tests/test_day27_benchmark.py:6 個離線測試要量測「規模」,需要一系列大小不同、但其他條件相同的規格書,而且每一份的正確答案都要已知。手工標註上百條需求的規格書並不實際,所以用程式產生:
| 設計 | 理由 |
|---|---|
| 大部分是「一般需求」(模組 × 功能,例如「會員中心支持深色模式」) | 貼近真實文件:大多數需求彼此無關 |
| 每 10 條需求放 1 組已知衝突 | 衝突是少數,不是 Day 7 資料集那樣放滿 |
| 三種衝突輪流:規則型、數值型、語意型 | 分別對應規則層、補充層 H2、補充層的語意理解 |
| 打亂順序,衝突的兩條不相鄰 | Day 24 看到補充層會配對「相鄰」的需求,不能讓它碰巧答對 |
| 不使用 v2 prompt 例子的主題 | Day 19 的原則:避免模型背答案 |
| 固定隨機種子 | 同樣的參數永遠產生同一份規格書 |
generate_srs(n, conflict_every=10, seed=27)
→ SyntheticSRS(constraints, expected_conflicts, conflict_kinds)
python3 -m src.benchmark rules --sizes 10 50 100 200 500 1000
→ 規則層:需求對數、候選數、規則型衝突召回、耗時、預估 LLM 呼叫
python3 -m src.benchmark llm --sizes 10 30 60 100 150
→ Day 20 設定(驗證 v1 + 補充 v2 + guardrail)完整檢測
→ 透過 Day 18 的 analyze() 計分與歸類
→ 另外請 Ollama 回報補充 prompt 實際讀到的 token 數
reports/day27_benchmark.md、reports/day27_benchmark_ctx8192.md
src/benchmark.py(新增);src/llm_verifier.py(修改:OLLAMA_NUM_CTX、OLLAMA_TIMEOUT、截斷警告)專案結構變化:
srs-review-agent/
├── src/
│ ├── benchmark.py ← 【新增】合成規格書、規則層擴展性、LLM 基準
│ └── llm_verifier.py ← 修改:num_ctx、timeout、PromptTruncatedWarning
├── tests/
│ └── test_day27_benchmark.py ← 【新增】6 個測試
└── reports/
├── day27_benchmark.md ← 【新增】預設上下文長度
└── day27_benchmark_ctx8192.md ← 【新增】num_ctx=8192
建立 src/benchmark.py:
# 一般需求:模組 × 功能。刻意避開規則關鍵字(多用戶、單用戶、加密、明文、刪除、保存)
# 與 Day 19 v2 prompt 例子中的主題(日誌、訂單、管理後台、上傳、檔案)
MODULES = ["會員中心", "商品目錄", "搜尋服務", "通知中心", "報表模組", "客服系統",
"行事曆", "知識庫", "排程服務", "儀表板", "推薦引擎", "評論系統"]
FEATURES = ["支持依名稱排序", "提供分頁瀏覽", "支持關鍵字篩選", "提供繁體中文介面", ...]
# 已知衝突的三種型態({m} 代入模組名稱)
CONFLICT_TEMPLATES = {
"rule": ("{m}支持多用戶同時操作", "{m}採用單用戶模式,同一時間只允許一人使用"),
"numeric": ("{m}每頁最多顯示 50 筆資料", "{m}每頁最多顯示 10 筆資料"),
"semantic": ("{m}的資料即時同步到伺服器", "{m}僅支援離線模式,完全不連線伺服器"),
}
12 個模組 × 12 種功能,共 144 句一般需求;超過時加上「(v2)」之類的版本號,確保每一條原文都不重複。generate_srs() 依序放入衝突、補上一般需求、打亂,最後編號為 REQ-1 到 REQ-n,同時記下哪兩條是一組衝突、屬於哪一型。完整代碼見 src/benchmark.py。
產生出來的 30 條需求片段:
REQ-1 報表模組支持深色模式
REQ-2 客服系統採用單用戶模式,同一時間只允許一人使用
REQ-5 評論系統的資料即時同步到伺服器
REQ-8 推薦引擎每頁最多顯示 10 筆資料
REQ-12 推薦引擎每頁最多顯示 50 筆資料
...
已知衝突:(REQ-2, REQ-26) 規則型、(REQ-5, REQ-23) 語意型、(REQ-8, REQ-12) 數值型
規則層不需要 LLM,可以量到上千條:
def measure_rules(n: int, batch_size: int = 10, seed: int = 27) -> RuleScaling:
srs = generate_srs(n, seed=seed)
detector = OptimizedDetector()
start = time.perf_counter()
candidates = detector._detect_simple_conflicts(srs.constraints)
seconds = time.perf_counter() - start
found = {frozenset((c.req_id_1, c.req_id_2)) for c in candidates}
rule_pairs = [p for p, kind in srs.conflict_kinds.items() if kind == "rule"]
recall = sum(1 for p in rule_pairs if frozenset(p) in found) / len(rule_pairs) if rule_pairs else 1.0
verify_calls = math.ceil(len(candidates) / batch_size)
...
除了候選數與耗時,也依 Day 12 的批量大小(每批 10 個)推算 LLM 呼叫次數:驗證 ⌈候選數 / 10⌉ 次、補充 1 次,開啟 Day 21 的第 4 層時再加上需求條數。
def supplement_prompt_tokens(constraints, version="v2", ...) -> Dict:
"""請 Ollama 只讀 prompt、不生成(num_predict=1),回報實際的 prompt token 數"""
prompt = build_supplement_prompt(constraints, version)
options = {"temperature": 0, "num_predict": 1}
if os.getenv("OLLAMA_NUM_CTX"): # 與 OllamaLLM 使用相同的上下文長度
options["num_ctx"] = int(os.getenv("OLLAMA_NUM_CTX"))
...
return {"chars": len(prompt), "prompt_tokens": data.get("prompt_eval_count")}
num_predict=1 讓模型只讀 prompt、幾乎不生成,幾秒就能得到 prompt_eval_count:Ollama 實際送進模型的 token 數。這個數字後來揭露了今天最重要的發現。
cd srs-review-agent
python3 -m pytest tests/test_day27_benchmark.py -v
tests/test_day27_benchmark.py::test_generator_is_deterministic PASSED [ 16%]
tests/test_day27_benchmark.py::test_generator_shape PASSED [ 33%]
tests/test_day27_benchmark.py::test_generator_large_and_invalid PASSED [ 50%]
tests/test_day27_benchmark.py::test_no_leak_from_prompt_examples PASSED [ 66%]
tests/test_day27_benchmark.py::test_rule_scaling PASSED [ 83%]
tests/test_day27_benchmark.py::test_truncation_warning PASSED [100%]
============================== 6 passed in 0.08s ===============================
python3 -m src.benchmark rules
| 需求數 | 需求對 | 規則候選 | 規則型衝突召回 | 耗時(毫秒) | 預估 LLM 呼叫(不含 / 含自我矛盾) |
|---|---|---|---|---|---|
| 10 | 45 | 1 | 100% | 0.0 | 2 / 12 |
| 50 | 1,225 | 4 | 100% | 0.3 | 2 / 52 |
| 100 | 4,950 | 16 | 100% | 1.0 | 3 / 103 |
| 200 | 19,900 | 49 | 100% | 4.1 | 6 / 206 |
| 500 | 124,750 | 289 | 100% | 28.1 | 30 / 530 |
| 1000 | 499,500 | 1156 | 100% | 111.3 | 117 / 1117 |
兩個觀察:
python3 -m src.benchmark llm --sizes 10 30 60 100 150 --out reports/day27_benchmark.md
| 需求數 | 已知衝突 | 補充 prompt token | Precision | Recall | F1 | FP(規則 / 補充) | FN | LLM 呼叫 | 秒 |
|---|---|---|---|---|---|---|---|---|---|
| 10 | 1 | 663 | 0.333 | 1.000 | 0.500 | 0 / 2 | 0 | 2 | 13.8 |
| 30 | 3 | 1461 | 0.500 | 0.667 | 0.571 | 0 / 2 | 1 | 2 | 18.8 |
| 60 | 6 | 2654 | 0.333 | 0.167 | 0.222 | 2 / 0 | 5 | 4 | 172.4 |
| 100 | 10 | 2051 | 0.200 | 0.300 | 0.240 | 12 / 0 | 7 | 8 | 681.0 |
| 150 | 15 | 2051 | 0.150 | 0.200 | 0.171 | 17 / 0 | 12 | 6 | 245.0 |
和 Day 20 在 Day 7 資料集上的 0.897 相比,落差非常大。把 60 條需求那一份用 Day 18 的分類拆開看:
| 結果 / 類別 | 數量 |
|---|---|
| TP / 規則層 | 1 |
| FP / 規則誤報、驗證放行 | 2 |
| FN / 規則命中、驗證誤刪 | 1 |
| FN / 兩層都沒找到 | 4 |
表格中最奇怪的是「補充 prompt token」這一欄:60 條需求 2654 個,100 條和 150 條卻都是 2051,比 60 條還少,而且兩者一模一樣。
prompt 明明變長了,模型讀到的卻變少了,代表 prompt 被截斷了。驗證方法是把上下文長度加大到 8192 再讀一次:
| 需求數 | prompt 字元 | 預設上下文長度 | num_ctx=8192 |
|---|---|---|---|
| 60 | 3,731 | 2,654 token | 2,654 token |
| 100 | 5,992 | 2,051 token | 4,221 token |
| 150 | 8,900 | 2,051 token | 6,303 token |
100 條需求的補充 prompt 實際有 4,221 個 token,超過了 Mistral 的 4096 上限,Ollama 就把它截到 2,051 個 token,沒有回報任何錯誤。模型只看到大約一半的需求,連 prompt 開頭的指示都可能被截掉。
Day 24 的 58 條規格書(2,623 token)還在上限之內,所以當時的問題是誤報,不是截斷。
修改 src/llm_verifier.py 的 OllamaLLM,做三件事:
# 實測(Mistral 7B,中文需求):每個字元約 0.70-0.71 個 token;低於 0.5 視為被截斷
MIN_TOKENS_PER_CHAR = 0.5
def __init__(self, model_name=None, base_url=None, timeout=None, num_ctx=None):
...
# 逾時(秒):Ollama 不串流時要等整段生成完才回應;加大 num_ctx 後一次可能超過 2 分鐘(Day 27 實測)
self.timeout = timeout or int(os.getenv("OLLAMA_TIMEOUT", "120"))
# 上下文長度;未設定時使用 Ollama / 模型的預設值
env_ctx = os.getenv("OLLAMA_NUM_CTX")
self.num_ctx = num_ctx or (int(env_ctx) if env_ctx else None)
self.last_prompt_tokens = data.get("prompt_eval_count")
if self.last_prompt_tokens and self.last_prompt_tokens < len(prompt) * self.MIN_TOKENS_PER_CHAR:
self.truncated_calls += 1
warnings.warn(
f"prompt 可能被截斷:{len(prompt)} 字元只讀了 {self.last_prompt_tokens} token。"
f"請設定 OLLAMA_NUM_CTX(例如 8192)或減少一次送出的需求數。",
PromptTruncatedWarning,
stacklevel=2,
)
OLLAMA_NUM_CTX:傳給 Ollama 的 options.num_ctx
OLLAMA_TIMEOUT:這是第一次用 num_ctx=8192 重跑時撞到的。60 條需求的補充層生成超過了原本寫死的 120 秒,guardrail 的連線重試 2 次之後放棄,整個基準測試中斷。上下文變大,生成也跟著變慢用 100 條需求實際呼叫一次:
預設上下文長度:tokens 2051,發出警告「prompt 可能被截斷:5992 字元只讀了 2051 token。請設定 OLLAMA_NUM_CTX(例如 8192)…」
num_ctx=8192:tokens 4221,沒有警告
tests/test_day27_benchmark.py 的 test_truncation_warning 用假的 urlopen 重現這兩種情況,並確認 num_ctx 確實傳給了 Ollama。
OLLAMA_NUM_CTX=8192 OLLAMA_TIMEOUT=900 python3 -m src.benchmark llm --sizes 60 100 \
--out reports/day27_benchmark_ctx8192.md
| 需求數 | 補充 prompt token | 結果 |
|---|---|---|
| 60 | 2,654 | F1 0.222,TP 1 / FP 2 / FN 5,與預設上下文長度完全相同 |
| 100 | 4,221(完整讀入) | 逾時:補充層單次呼叫超過 900 秒,重試 2 次後放棄;整個實驗約 63 分鐘後中斷 |
60 條需求本來就沒有被截斷(2,654 < 4,096),加大上下文當然不會改變結果。真正的問題在 100 條:prompt 終於被完整讀入了,但 Mistral 7B 在這台機器上一次生成就超過 15 分鐘。可能的原因是 4,221 個 token 的輸入本身就要較長的處理時間,而補充層又要為 100 條需求輸出一份 JSON;本文沒有進一步拆解每一段的耗時。
所以「加大上下文」在技術上解決了截斷,在實務上卻不可行。這一輪實驗也讓 src/benchmark.py 多了一段處理:某個規模逾時時,記錄一列「逾時」並繼續下一個規模,而不是讓整個基準中斷:
try:
report = analyze([srs], llm=OllamaLLM(), **CONFIGS["improved"])[0]
except ConnectionError:
# 加大 num_ctx 後,一次生成可能超過 OLLAMA_TIMEOUT(Day 27 實測);記錄後繼續下一個規模
...
continue
reports/day27_benchmark_ctx8192.md 依這次的執行紀錄整理;沒有記錄到的欄位(60 條的呼叫次數與秒數)標示為「未記錄」,不另外估算。
| 規模 | 狀況 | 建議 |
|---|---|---|
| 約 30 條以內,且衝突不少 | 接近 Day 7 資料集的條件,F1 0.5-0.9 | 完整設定(驗證 + 補充) |
| 約 60 條 | 補充層沉默、跨模組的規則誤報開始出現 | --no-supplement,結果全部人工複核 |
| 100 條以上 | 補充 prompt 超過 4096 token 被截斷;加大上下文則太慢 | --no-supplement;或把規格書依章節拆開分析 |
規模變大時,最先出問題的是補充層(截斷、沉默、誤報),其次是規則層的跨模組配對(候選數約為 k²)。驗證層本身在 60 條時也出現了第一次誤刪。這三個問題都不是調 prompt 能解決的,需要改變架構:補充層依章節分批送出、規則層加上「描述對象」的比對。
git add src/benchmark.py src/llm_verifier.py tests/test_day27_benchmark.py \
reports/day27_benchmark.md reports/day27_benchmark_ctx8192.md
git commit -m "Day 27: 大規模基準、prompt 截斷偵測、OLLAMA_NUM_CTX 與 OLLAMA_TIMEOUT"
這兩天累積了不少使用者必須知道的事:補充層在大型規格書上的誤報、--no-supplement、OLLAMA_NUM_CTX、OLLAMA_TIMEOUT,還有 Day 13-26 散落在各篇文章的環境變數與參數。明天 Day 28 我們把它們整理成一份完整的使用指南,並用測試確保文件與程式不會再各說各話。